Skip to content

米鹿DSL可视化编排的实践总结

最近开发了一套DSL可视化编排系统,已上线并在业务场景中得到应用。本文针对系统所涉及的业务需求、设计方案、工程实践以及关键问题等方面进行了梳理总结,希望能给有类似需求的同学们提供一些的帮助。

系统简介

见名知意“DSL可视化编排”,就是通过可视化的方式,在画布上拖拽一些组件,设置一些属性,最终生成DSL代码。那么,这里的DSL代码是指啥呢,毕竟"DSL"这个术语所表示的概念很宽泛的。

项目背景

通用 DSL 规范脱胎于 Ares、VL、VN Desktop 多家 DSL 语言,是由动态化中台 - DSL 工作组共同制定的适用于多平台(App、H5、小程序、桌面)的动态化通用描述性语言。

DSL代码示例

就是中台工作组的老师们,开发的一套通用描述语言。一个常规的DSL文件,以.vl为后缀,包含template、style、props、data、script五个部分,除了template部分外其余都是可选的。类似Vue组件的组成结构,熟悉前端的小伙伴们应该很有亲切感。有关DSL更多的语法介绍,可以参见附录7

使用DSL有什么好处呢?编写的DSL代码,经过DSL编译器的处理,可以转化成Web和原生平台的代码,进而实现“开发一次,多平台运行”的目的。

编译成跨平台代码

目前该DSL,在新闻客户端已经得到广泛应用,比如首页的各种图文卡片就基于DSL实现。客户端老师也开发了各种DSL组件,便于在更多场景中复用和拓展。虽然,在实际应用的过程中,DSL编译出的产物,在Android和iOS两个平台有运行效果不一致的兼容性问题,但和中台负责DSL语言研发的老师反馈后还是能解决的,并且在一定程度上提升了开发效率。

系统目标

由于DSL和前端中的Web组件具有很近的“血缘”关系(各部分的组成结构,和Vue组件相似),能否在web浏览器中通过可视化的方式动态生成DSL,来进一步提高生产力。如果是编排模板、样式以及支持数据和属性部分,在技术是可行的。针对逻辑部分的脚本代码还无法实现可视化的编排,需要开发人员辅助实现。同时,腾讯新闻平台的前端组出品了米鹿这一优秀的创辅工具,可以利用其现有的画布编辑能力,缩短目标达成时间,进一步降低目标实现难度。此外,使用DSL开发原生应用的客户端老师们,由于平时不怎么接触CSS,对于DSL的样式布局及视觉属性的配置略显吃力,开发一套DSL的可视化编排系统,可辅助提升他们的研发效率。先由编排系统拖拽产生初级的DSL,再由客户端老师们在真机上调试优化,最终形成一系列可复用且稳定的DSL组件。

系统目标

因此,系统目标可概括如下几点:

  • 依托米鹿沉淀出的强大创辅能力,在米鹿平台上开发一套满足客户端老师需求的DSL可视化编排系统
  • 该系统需要支持DSL的可视化编排,产生修改后的DSL代码,支持实时预览
  • 打通新闻客户端的发布流程,编排系统产生的DSL,可以间接应用到客户端上

系统初体验

目前米鹿DSL可视化编排系统已经上线一版,完成了系统目标中所描述的前两点,处于用户体验的内测阶段。

系统UI

整体的界面布局,沿用米鹿平台目前的风格。针对DSL的特点,打造了全新的组件体系,目前组件分为布局组件和功能组件。针对DSL组件的特点,升级了右侧的属性面板。右上方的功能栏,有“发布”、“预览”等交互按钮,其中点击“生成DSL”按钮后,可以实时查看当前画布编排出的DSL代码,支持代码的复制导出,用于后续的调优。如果想更深入地了解本系统,欢迎点击附录3的体验地址。

设计方案

设计方案,主要从“实现原理”,“需要考虑的关键技术点”,“画布布局的选择”,“从画布生成DSL的模板代码”,“AST结点树如何设计”,“从画布生成DSL的样式代码”,“怎么快速访问组件树上的某个结点”和“从VL到画布的反向解析”这几个方面来说明。其中AST结点树的设计是较为关键的一环,生成DSL模板代码以及样式代码是核心功能,布局选择是否合理直接决定了实现成本及交互体验,设计一种优雅的方式快速访问树状结点可以降低心智负担同时提升功能稳定性,把一段DSL组件代码反向输出到米鹿画布上是实现难度较大的功能点。

设计实现原理

包括正向和反向两个方面。

  • 在米鹿画布上通过编排视觉组件生成DSL代码(正向处理过程)
  • 解析DSL组件代码并可视化地展示到米鹿画布(反向处理过程)

设计实现原理

米鹿画布是米鹿平台的核心基础能力,通过拖拽视觉组件,生成特定的视觉UI。比如,运营老师经常使用米鹿平台快速搭建H5活动页,就是利用了米鹿强大的画布拖拽能力,像搭积木一样快速产生想要的作品,并打通了发布流程,支持一键上线。而本文介绍的DSL可视化编排,同样利用了米鹿画布,区别在于视觉组件的表意不同。米鹿DSL编排的画布,主要通过可视化的编排方式输出DSL代码,它背后的组件元素、属性配置以及层级关系都是为这一目标服务的。

构建AST结点树,是生成DSL代码,以及反向解析并展示到画布的关键一环。每个结点可以映射成DSL模板中的标签,属性信息转化为标签的attribute,结点树的层级转换成标签之间的嵌套关系。DSL代码是系统的最终产物,编排系统产生的代码需要推送到远程代码仓库,通过流水线下发到客户端。总体而言,本系统的核心实现原理就是,把米鹿画布上的视觉组件,映射成抽象语法树,再转换成DSL代码,完成正向处理过程;针对已封装的复杂DSL组件,能解析成AST结点树,然后以可视化的方式展示在米鹿画布上,完成反向处理过程。

需要考虑的点

米鹿DSL可视化编排系统在设计时,需要考虑如下几件事。

  • 画布采用何种布局方式
  • 打造什么样的组件体系
  • AST结点树如何设计
  • 如何打通整个链路。

考虑的点

米鹿原本是为运营老师打造的一款快速生成活动页的创辅工具平台,用它来生成DSL代码是近期的业务需求,现有的功能设计适合生成DSL代码吗,这值得商榷,在下面一节我们重点讨论。因为目标变成了生成DSL代码,不再是生成活动页,所以之前的视觉组件就不能用了,需要打造全新的组件体系以及配套的属性设置。AST结点设计的好坏,直接关系到代码生成的质量,以及系统的复杂度,是非常重要的环节。最后就是打通链路,将生成的DSL代码最终下发到客户端,系统才会产生实际的应用价值。

布局的选择

当前的米鹿平台,采用的是绝对定位布局。这种布局方式,不涉及到嵌套关系,所有的视觉元素都平铺到画布上,以绝对的数值坐标来控制组件的摆放位置,以层级(CSS中的z-index属性,数值越小就隐藏到后面)的优先级来控制叠加元素的展示与隐藏。绝对定位的布局方式,很适合不太懂技术的运营老师,拖拖拽拽十分灵活地布局页面结构,调整外观属性(宽高、位置、间距等)也很方便。对于原本生成运营活动页的需求,这样的布局方案是很合理的。如果米鹿DSL编排系统,也沿用这样的布局,是否可行呢?

布局的选择

对于DSL的可视化编排,目标已经变了,不再是生成页面,而是产生高质量、可阅读的DSL代码。绝对定位布局无法语义化地表达组件间的嵌套关系,因为本文中介绍的DSL模板是有嵌套层级的。那如何通过,画布中的视觉组件来映射这种嵌套组合关系呢?显然,只有一种层级的绝对定位布局,无法很好的满足这一需求。当然,通过维护额外的层级关系可以实现这一目标,比如给每一种视觉组件增加一个类似parentID的属性来标识它的父级。但这样的后果是,每向画布添加一个组件,就要设置它的父级,操作就会比较繁琐,无形中增加了编排工作量。

为了解决该问题,米鹿DSL编排系统采用了文档流式布局,就是浏览器中HTML的默认布局方式。流式布局中,每个组件元素都占据文档流中的空间,默认从上到下、从左到右依次排列,先天支持视觉组件的嵌套,无需手动维护层级关系。同样这种流式布局也是有缺陷的,拖放组件不是很灵活(尤其是在热区比较密集的情况下),无法像绝对定位布局那样随心所欲,想把组件拖放到画布的什么位置都行。这是由于在文档流式布局中,存在热区和容器组件的概念,只能将待选组件拖放到容器组件的热区内,这限制了画布的灵活性。而且,当前的米鹿画布还不支持流式布局,需要全新开发,在一定程度上加大了开发成本。

针对上面提到的文档流式布局拖放不方便的问题,可以采取其它方法来规避。同时,流式布局和生成DSL代码之间的关系更加语义化,两者存在比较直观的映射联系。而且DSL可视化编排系统,面向的主要用户群体是客户端开发以及产品老师,他们多少都对web技术了解一些,不同于运营活动页的情况,可视适当提高系统的操作门槛。因此综合考虑,我们选择文档流式布局,来作为米鹿DSL可视化编排系统的底层画布布局方式。

从画布到模板代码

从画布生成DSL的模板代码,是编排系统的基础功能。将画布上的视觉组件,通过映射关系转换成AST结点树,再通过对结点树的遍历转化操作生成目标代码。

画布到模板

如上图所示,最左侧是一个简单的三层嵌套布局,最层外层可以理解为画布根容器,子级是两个容器A、B,最里面是普通的组件A1、A2和B1、B2。画布上的布局结构可以转化成中间图示的抽象结点树,顶层是“Root”结点,中间层是容器A和容器B两个子结点,再往里是组件A1、A2、B1、B2构成的叶子结点。最后,遍历结点树顺理成章地转化成了最右侧的模板代码(这里仅是简单示意,不包含标签属性部分的逻辑)。

AST结点树的设计

AST(Abstract Syntax Tree)存在于每一门编程语言的背后,是将高级语言解释成目标可执行程序的中间产物,以便于计算机最终“读懂”程序。在米鹿DSL编排系统中,也采用了类似的数据结构,作为中间产物,以便进一步转化成目标DSL代码。

AST设计

如上图所示,每一个树状结点可以理解成JavaScript中的对象,对象上存在很多的属性。比如,tag属性会转换成对应的模板标签(在本系统中,root映射成template标签),children属性会形成标签之间的嵌套关系,uuid用于唯一标识一个树状结点。此外,props属性又是一个复合对象,转换成标签的内部属性(attribute),比如id类似于浏览器DOM结点中的唯一标识,class表示样式类属性,还有很多DSL特有的属性(vl:if,用于是否展示标签;@tap,用于原生的点击事件等)。

从画布到样式代码

在DSL编排系统中,是怎么处理样式部分的呢?画布上的视觉组件,是自带样式规则的,怎么将这部分样式属性收集起来,组合成DSL的样式代码呢?

画布到样式

如上图,左侧是画布上描述图文卡片(左文右图)的视觉组件的布局,右侧是对应DSL的样式代码。最外层的“图文卡片”容器(在本系统中是一个名为“dsl-view”的树结点)有一个样式类“.card-wrap”,它的子级为两个容器(.lft-box和.rgt-box),其中左侧的容器还有“文章标题”,“文章摘要”等子元素。每个样式类里,存放的是像Web CSS中的样式规则,可以看到大小单位不是px而是rpx。目前DSL中支持的样式规则,是全部CSS的一个子级,单位有rpx、dp、vw、vh以及百分比等,布局方案采用flex布局。

画布到样式

本系统处理样式的逻辑,类似于处理模板。首先会获取画布上的视觉组件的布局结构,然后转化成一颗类似上图左侧的样式结点树,采集结点上的样式规则和样式类(如果用户没有设定,则自动生成一个),接着深度递归遍历整棵树直到处理完所有结点,这样就获得[{样式类: {样式规则}}, {样式类: {样式规则}}, ...]一维的样式列表。过滤掉其中无效的样式属性和值后,然后针对涉及到大小的单位(大小为px的情况、没有单位的情况以及特殊属性)做转换处理,转换的规则由用户设置。经过上述步骤的处理,得到了初步的样式表,还需要考虑继承外部(父级组件)样式的情况,如果有相同的样式类要合并在一起,最终将样式列表输出到<style>...</style>标签中间。

快速访问结点

在画布上任意选择一个视觉组件时,如何快速定位出它在整棵树(由视觉组件映射成的AST结点树)上的结点位置呢?这是个很重要问题,关系到诸如“组件选择”、“选择父级”、“组件的位置变动”等高频业务场景。

在设计AST结点树时,给每个结点增加了一个accessPath属性,用于快速访问树上的任意结点。如上图所示,根结点的accessPath属性为空,其直接子结点的accessPath属性分别为‘-0’、‘-1’、‘-2’,第三层的孙子辈结点依次为‘-0-0’、‘-0-1’、‘-0-2’。在系统中,很容易获得树的根结点,在此前提下,如果想访问C结点,只需拆解出C结点的accessPath属性,获得[0][2][0][1]这样的children路径即可。这样可以在O(log(n))的算法时间复杂度内访问树上的任意结点。

这样设计有没有什么弊端呢?也是有的,需要我们手动去维护accessPath这种属性关系,一旦结点关系变化时,需要同步更新同级以及子孙结点。比如,要交换上图中结点A、B的位置,则交换后结点A的accessPath变成-0-2,结点B的accessPath属性变成了‘-0-0’,同时B的所有子孙结点的accessPath都需要同步变更(成-0-0-x-0-0-x-x-0-0-x-x-x等)才可以。从这里可以看出,这种增加accessPath属性来提升结点访问速度的设计,同时也增加了结点树的更新成本,有利有弊。对于本系统的DSL编排功能来说,这种设计是必要的,将更新结点的操作封装在一个独立的函数内,当作黑盒使用,在复杂的逻辑处理场景下,可降低心智负担,增强结点树的稳定性和访问速度。

VL到画布的反向解析

米鹿DSL可视化编排系统,除了生成DSL代码外,另外一个重要功能是解析已存在的DSL组件(一般是客户端老师开发的,DSL组件文件的后缀为.vl),并将其以可视化的方式展现到米鹿画布上,也即我们上文的设计实现原理一节提到的“反向解析,输出画布”的过程。

VL到画布

如上图左侧的vl文件,表示一段DSL组件代码,我们要将其解析成画布上的视觉元素,展示成右侧所示的样子。

VL到画布2

大致的处理流程如上图所示,首先解析manifest文件(DSL组件的信息描述文件),提取远程组件库(客户端老师封装好的DSL组件,详见附录6)中的公共组件,然后读取vl文件内容,经过VL解析器的处理,得到AST结点树,接着将AST结点树转化成Web视觉组件,最终将视觉组件展示到米鹿画布上。其中,AST结点树转换Web视觉组件这一环节,又包含诸多子过程。要把DSL组件的模板标签映射成如div、p等浏览器识别的HTML标签,需要将DSL的样式代码,解析成浏览器识别的CSS代码,同时涉及到大小单位的转换如rpx、dp等转换成px,对部分特殊的DSL样式做兼容处理,然后将解析好的web模板和样式重新组织成视觉组件。

VL内容的解析,也是其中一个比较复杂的处理过程。首先获得DSL组件的内容,接着扫描模板字符,判断<的位置(可能是开始标签、结束标签、注释结点、文本内容),然后使用正则表达式匹配,提取出结点,这样循环往复,直到处理完整个模板字符串。

VL到画布3

关于<的位置判断,以及结点的拆解。我们可以看上面的图,更好地理解。<的位置无外乎三种情况,我们这里暂时不考虑文本内容中又包含‘<’的情况。

  • <的位置等于0,则匹配到标签结点(起始标签、结束标签、注释标签中的一种)
  • <的位置小于0,剩余的模板部分全都是文本内容了,可以直接结束解析了。
  • <的位置大于0,当前位置(处理到的模板字符的位置)到下一个<的位置之间为文本内容

这样分情况讨论,逐一细化问题,最终分而治之。其中,第一种情况又最为复杂,需要处理三种标签情况。

VL到画布4

我们通过上面一张完整的流程图,来进一步解释下VL的解析过程。一开始定位到模板字符串的起始位置(cursor=0),然后判断下个<的位置(positionIndex)。

假如当前定位的字符位置(cursor)大于< 的位置(positionIndex),说明整个模板字符串就没有待处理的标签结点了,全部为文本内容,可以终止结束流程了。

javascript
// 提取剩余的模板字符串
const textNode = remainingTemplate;
// 字符游标前移到末尾
cursor += textNode.length;
// 游标位置已经达到模板字符串的尾部,结束整个解析过程
if (cursor >= templateLength) {
  return;
}
// 提取剩余的模板字符串
const textNode = remainingTemplate;
// 字符游标前移到末尾
cursor += textNode.length;
// 游标位置已经达到模板字符串的尾部,结束整个解析过程
if (cursor >= templateLength) {
  return;
}

假如当前定位的字符位置(cursor)小于< 的位置(positionIndex),则说明当前字符和“<”之间的字符串为文本内容,提取两者之间的部分就可获得文本结点了,然后游标cursor移动到当前< 的位置(positionIndex),待处理模板的内容进一步缩减。

javascript
// 提取 当前字符 和 < 字符之间的文本内容
const textNode = remainingTemplate.substring(0, positionIndex);
// 字符游标前移文本内容的长度
cursor += textNode.length;
// 动态改变待处理模板的内容
remainingTemplate = template.substring(cursor);
// 提取 当前字符 和 < 字符之间的文本内容
const textNode = remainingTemplate.substring(0, positionIndex);
// 字符游标前移文本内容的长度
cursor += textNode.length;
// 动态改变待处理模板的内容
remainingTemplate = template.substring(cursor);

假如当前定位的字符位置(cursor)等于下个<的位置(positionIndex),说明当前字符cursor正好是个标签结点,至于属于哪种情况(起始标签,结束标签,注释标签)还需进一步匹配。

javascript
// 开始标签:<view class="card-wrap" ...>的情况
if (remainingTemplate.match(startTagRegExp)) {
  // 提取起始标签结点的内容
  const startTagContent = matchResult[0];
  // 提取标签中的属性
  // 如果不是单标签结点,进入匹配栈 matchStack;
  // 是单标签的情况,则形成一个AST结点
  // 字符游标前移到处理过的位置
  cursor += startTagContent.length
  // 动态改变待处理模板的内容
  remainingTemplate = template.substring(cursor);
  // 进入下一轮的处理过程
}
// 结束标签:</view>  的情况
if (remainingTemplate.match(endTagRegExp)) {
  // 提取结束标签结点的内容
  const endTagContent = matchResult[0];
  // 提取出标签名称,和 匹配栈 matchStack 的栈顶元素进行对比
  // 如果一致,则 匹配栈 matchStack 的栈顶元素 出栈,形成一个AST结点
  // 如果不一致,则说明模板中存在如‘<view></text>’这种非法的情况,抛出错误,结束处理过程
  // 没报异常,走到这里后,前移游标字符,改变待处理模板
  cursor += endTagContent.length
  remainingTemplate = template.substring(cursor);
  // 进入下一轮的处理过程
}
// 注释标签:<!--处理iOS文本无法垂直居中问题-->的情况
if (remainingTemplate.match(commentTagRegExp)) {
  // 提取注释标签结点的内容
  const commentTagContent = matchResult[0];
  // 视业务情况进一步提取,加工
  // 和上面其它标签结点的情况一样,移动当前字符游标,改变剩余待处理模板
  cursor += endTagContent.length
  remainingTemplate = template.substring(cursor);
  // 进入下一轮的处理过程
}
// 开始标签:<view class="card-wrap" ...>的情况
if (remainingTemplate.match(startTagRegExp)) {
  // 提取起始标签结点的内容
  const startTagContent = matchResult[0];
  // 提取标签中的属性
  // 如果不是单标签结点,进入匹配栈 matchStack;
  // 是单标签的情况,则形成一个AST结点
  // 字符游标前移到处理过的位置
  cursor += startTagContent.length
  // 动态改变待处理模板的内容
  remainingTemplate = template.substring(cursor);
  // 进入下一轮的处理过程
}
// 结束标签:</view>  的情况
if (remainingTemplate.match(endTagRegExp)) {
  // 提取结束标签结点的内容
  const endTagContent = matchResult[0];
  // 提取出标签名称,和 匹配栈 matchStack 的栈顶元素进行对比
  // 如果一致,则 匹配栈 matchStack 的栈顶元素 出栈,形成一个AST结点
  // 如果不一致,则说明模板中存在如‘<view></text>’这种非法的情况,抛出错误,结束处理过程
  // 没报异常,走到这里后,前移游标字符,改变待处理模板
  cursor += endTagContent.length
  remainingTemplate = template.substring(cursor);
  // 进入下一轮的处理过程
}
// 注释标签:<!--处理iOS文本无法垂直居中问题-->的情况
if (remainingTemplate.match(commentTagRegExp)) {
  // 提取注释标签结点的内容
  const commentTagContent = matchResult[0];
  // 视业务情况进一步提取,加工
  // 和上面其它标签结点的情况一样,移动当前字符游标,改变剩余待处理模板
  cursor += endTagContent.length
  remainingTemplate = template.substring(cursor);
  // 进入下一轮的处理过程
}

如果想了解更多关于VL解析这块的处理逻辑,可以到附录4项目工程里的src/core/vl-parser.ts路径下查看代码实现。

工程落地

软件工程,一般包含需求分析,整体设计,系统测试部署等环节。这里我们简要阐述一下米鹿DSL可视化编排系统的业务流程、整体架构设计以及测试发布的具体实践过程。

业务流程分析

整体的流程可以划分为五个阶段:初始化、载入组件、布局设置、生成代码和发布。其中“初始化”和“布局设置”环节需要较多的人工参与,其它环节多半是自动化或程序的流转处理。 业务流程 如上图所示,在初始化阶段,操作人员登录米鹿平台,创建一个DSL可视化编排的项目,之后进入到DSL的编排页面,在这里进行画布的大小调整、参数设置以及基础调试数据的配置。然后进入到“载入组件”阶段,由米鹿平台调用@tencent/elk-dsl库,在该NPM包的内部先加载预置组件,后读取manifest文件,载入远程DSL组件,然后将两者(静态预置组件和远程动态组件)合并,展示在米鹿平台的左侧组件菜单上。布局设置,就是对各种视觉组件进行编排操作,是系统的核心流程,首先从组件菜单中选择某个组件,拖放到中间的画布区域,进行位置的调整,在右侧的属性面板上调整组件的属性参数,如此循环往复,直到完成整体的布局编排。

接下来是“生成代码”阶段,是系统的重要处理环节,同样是在@tencent/elk-dsl包内完成。首先从米鹿平台获取相关的编排配置,根据配置信息生成AST结点树,然后映射转化,生成原始的DSL代码。在输出前,再进行代码的格式美化,回显到米鹿平台。确认没问题后,即可发布到流水线,进入“发布”阶段。和客户端老师约定,先将生成好的DSL代码上传至git仓库的某个开发分支,人工审核通过后合并到主干分支,然后进入到自动化的发布流水线,最终下发到客户端。

整体架构设计

整个米鹿DSL可视化编排系统,涉及到三大板块:米鹿创辅平台、DSL编排工具包(@tencent/elk-dsl)以及客户端的DSL组件库。

整体架构

米鹿平台,是编排系统的基础,经过腾讯新闻平台前端组的多位开发老师的不懈努力,时至今日已经变成了功能丰富的运营活动创辅平台,除了本文重点介绍的DSL编排功能外,还有其它很多重量级的功能特性,欢迎感兴趣的老师,到附录3的地址去体验更多功能。米鹿平台,整体是一个Web系统,分为前后端,部署在公司123平台上,底层依赖于Mongo数据库(主要存储项目配置信息),使用Redis进行缓存加速,不涉及外网访问的页面资源直接上传至nodeJS服务端,对外发布的页面走章鱼分发进行上线。米鹿平台的后端是典型的NodeJS服务,主体采用koa作为Web框架,集成了很多中间件,提供数据持久化、模板渲染、路由管理等功能,为上层的米鹿前台提供服务支撑。米鹿的前台包含,模板类H5、长页H5、海报图、图表、DSL编排等功能视图。前台提供了很多基础能力,比如基础画布、拖放能力、创辅工具组件等。基于米鹿平台的基础能力,拓展了DSL可视化编排系统。

米鹿DSL可视化编排系统的主体逻辑,另起了一个新的工程项目,编译后打包到@tencent/elk-dsl中,然后由米鹿平台引入。和UI交互相关的逻辑,依然在米鹿平台的前端直接实现。在elk-dsl项目中,整体逻辑架构可以划分成三层,分别为:基础核心层、DSL处理层和对外接口层。基础核心层,包含基础模型、核心工具套件、树状结构数据的操作、定义的抽象类型以及公共常量、VL解析器、样式的通用处理以及代码美化等功能模块。其中VL解析部分在上面的章节详细讨论过;基础模型部分是关于DSL组件的抽象模型定义,可算作是业务模型的基类;树状结构部分是针对AST结点树的遍历、提取及访问的封装模块;样式处理部会对Web的CSS代码转换成DSL组件的style部分,并在反向解析DSL组件时,生成浏览器可识别的CSS代码,其中也会涉及到单位的转换处理,无效值的过滤等逻辑;此外,类型声明和常量枚举部分,是对系统通用数据结构的约定模块。

DSL处理层,主要是DSL代码的生成逻辑,也包括对DSL组件的二次加工和编排功能。它介于基础核心层和上层接口层之间,起到承接上下的作用,是主要业务逻辑的处理层。包含:接口配置、数据预处理、内置布局、DSL常量、元素映射、派生模型和代码生成等功能模块。接口配置部分,接收上层接口层传递的米鹿配置;派生模型部分,是继承核心层的基础模型,用于满足定义多样化DSL组件的需求,比如有根DSL组件、原生DSL组件、封装DSL组件以及自定义DSL组件等;元素的映射部分,则针对AST结点到模板标签转换的时候,生成模板标签的功能,有时二者之间并不是简单的一一对应关系,需要根据参数条件组合判断;内置布局部分,对上层接口层提供处理好的布局组件结构,这样上层的接口层拿到数据后,传输给米鹿平台;DSL常量部分,很好理解,定义了关于DSL组件的数据规范;统一ID部分,则是自动化生成样式类,结点ID以及其它需要自增数值的地方提供统一的处理。

上层接口层,主要处理和米鹿平台以及客户端DSL组件仓库的交互。包括:远程接口服务、拉取远程组件、接收米鹿平台的参数、DSL的通用设置、开放给米鹿平台的接口、调用示例、预置的组件及主题样式等。在这一层会接收来自米鹿平台(米鹿前端画布)的配置参数,并调用上面介绍的DSL处理层的功能函数,将配置参数转换成AST结点树,最终生成DSL代码,即上文提到的“从米鹿画布到生成DSL的正向处理过程”。在这一层会调用工蜂git提供的开放API,请求客户端仓库中的公共组件,然后调用DSL处理层的功能函数,将其解析成AST结点树,再转化成可视化的Web组件,最后展示在米鹿平台,即上文提到的“从VL到米鹿画布的反向解释”过程。

系统测试集成

米鹿DSL可视化编排系统,工程结构是什么样的,怎么做的测试,怎么集成到米鹿平台,用到了哪些关键技术?

集成测试

项目(这里是指elk-dsl,不包括米鹿平台)的工程目录如上图左侧所示,主要包括:src(源代码)、test(单元测试)、dist(编译后的js)目录以及package.json、tsconfig.json等配置文件。项目使用到了typescript,针对相对路径模块的引用采用tsc-alias转换成绝对路径。源码目录(src)编译到dist目录下,后发布NPM包(名称为:@tencent/elk-dsl)到公司仓库,米鹿平台从公司私有NPM仓库引入@tencent/elk-dsl。test目录和src目录平级,里面的目录及文件结构保持一致,如【src/core/vl-parser.ts】对应的是【test/core/vl-parser.test.ts】,项目的单元测试使用jest框架,由于是typescript编写的项目,所以要额外引入ts-jest库。同时,在做网络请求的测试时,依赖于jest-fetch-mock库,保证在jest测试环境中发起的网络请求能抵达真实的API服务。另外,项目的整体代码规范使用公司统一规定的@tencent/eslint-config-tencent,保证在项目代码提交之前,会触发两个husky(lint-staged:代码改动的规范校验,commit-lint:提交信息的规范校验),校验通过后才有可能进入远程git仓库。

关键问题及解决方案

在系统具体的功能实现及应用落地的过程中,又遇到了很多关键性的问题。这里节选其中比较典型的三点加以详细讨论。包括:热区比较密集,无法拖放到预期的位置;画布上布局嵌套的层级很深,无法快速识别选择;属性参数繁杂,增加了使用门槛。怎么解决这个三个问题呢?

热区密集,无法准确拖放

DSL编排系统提供的组件,包括几类:普通容器、功能组件、布局组件、原生组件以及封装组件等。其中,普通容器和布局组件,都存在热区,也即它们里面可以拖放其它组件(也包括容器组件和布局组件)。这样当画布上容器组件多时,就会因热区过多,造成无法准确拖放的问题。

热区密集

如上图所示,当前画布上有个A容器组件,里面包含两个容器组件B和C,现在想从左侧的组件列表中拖出一个容器组件(假设叫“D”)放在B、C容器组件之间,看似很简单的一个操作。目标效果是:B、D、C三个容器组件依次排列在A容器中。但实际操作时,很容易产生图中右下角所示的效果:D组件进入到了B容器或C容器中,形成了B容器(或C容器)嵌套D容器的效果,这就是问题。

热区密集-2

如上图所示,我们再列举一个常见的业务场景,画布上的A容器当前有B、C两个子级容器,现在的目标是交换BC的位置,同样看似很简单的一个操作。可实际操作时,很容易产生B容器进入到C容器(或C容器进入到B里)的效果,这就是问题。

为什么会产生上面的这两种问题呢?因为当前画布使用的是文档流式布局,而非绝对定位,每个组件都占据了文档流中的布局空间。而容器组件自带拖放热区的特性,鼠标在画布上拖拽某个组件时,自然会经过这些热区,如果拖放的目标区域离这些热区非常近又非常狭小,很容易进入到这些临近的容器热区,产生了非预期的效果。例如,上面的场景一,想把D拖放到B、C容器之间,目标区域(红色虚框标注的地方)太小了,在拖放时很大概率就进入到了临近的B或C容器中了。

那怎么解决这类问题呢?主要是站在产品设计的角度去考虑方案的。

热区密集-3

针对上面提到的场景一,把D容器放置在A容器中的B、C之间。如上图所示,首先选中A容器,双击组件列表中要添加的(D容器)组件图标,这样D容器就进入A容器的末尾,然后选中D容器,弹出右键菜单,选择“位置前移”一项,如此操作就完成了目标效果。

热区密集-4

针对上面提到的交换容器位置的场景二,如何操作,规避问题呢?首先,选择A容器,再点击画布右上角的“放大镜”按钮,此时会弹出一个“快速拖放栏”,里面展示的是A容器的直接子级组件,然后在快速拖放栏里上下移动里面的视图组件,很容易同步改变画布上组件的位置。这个“快速拖放栏”中的视图组件,是不存在热区的,即使代表的是容器组件,上下移动,都不会造成组件间的嵌套关系,只能改变它们之间的相对位置。

嵌套层级深,无法快速识别

画布上的布局比较复杂,组件间产生了比较深的嵌套关系,此时肉眼已经无法快速选中某个视图组件了,该怎么办?

组件树

如上图所示,想选中红色区域的容器,是不容易操作的。因为红色区域代表的容器嵌套了一些子级元素,同时它又被外层的容器所包裹,这样想选中这个容器,就只能点击四周狭窄的线框区域了,很容易选成父级或子级组件。为应对该情况,在画布的右上角,提供了一个“组件树”的按钮,点开后弹出一个组件树。上面分层级地展示了画布上的所有视图组件,可以清晰的知道想要选中的组件在哪里。点击组件树上的结点,就可同步选中画布上的功能组件了。

属性繁杂,增加使用门槛

客户端老师反馈在使用DSL编排系统时,不知道如何设置一些属性,尤其是样式这块儿的配置,因为CSS本身的属性就非常多,完全掌握的话还是有一定学习成本的,如果之前没有接触过的话,使用本系统肯定会感觉有门槛的。如何解决该问题呢?

属性编辑

DSL编排系统重新改造了米鹿平台的右侧属性面板,提供了“选项配置”和“自定义配置”两种选择。选项配置,如上图左侧所示,提供了很多可视化的参数配置,可以清晰地知道当前配置的含义,轻松改变组件的视觉效果。同时“自定义配置”面板,提供代码级别的参数配置方案,可以了解当前选中组件的属性设置。支持“选项配置”、“自定义配置”和画布组件的同步联动,即选项配置中设置“宽”为240px,则在画布上会看到组件的视觉宽度变了,同时“自定义配置”栏中的style.width也会同步变成240px,同样改变自定义配置栏中的属性,也会同步到“选项配置”栏和画布中。

属性编辑-2

DSL的样式使用flex布局方案,这一块客户端老师并不太熟悉。为了解决该问题,如上图所示(节选部分),系统提供了13种布局组件,可以一键拖拽生成想要的布局效果,同时布局组件是一种复杂的高级容器,也是支持嵌套的,这样基本能组合成想要的各种布局效果。此外,基本容器和布局组件也可相互嵌套,随意使用,没有什么约束。本质上,布局组件就是由基本容器组件按照预定的样式规则组合成的。在上面的整体架构设计一节介绍DSL层时,提到了关于“内置布局”的功能模块,和这里的布局组件方案有着紧密联系,就是依托DSL层“内置布局”的功能模块,在米鹿平台上拓展实现的。

下步计划

目前米鹿DSL可视化编排系统已经上线,客户端以及产品等同学在内测体验中。针对他们反馈出的系统问题,会及时跟进,并一一解决。下一步要做两件事情:打通米鹿DSL编排系统和客户端的全链路;提供更多的技术支持来满足客户端老师们的业务需求。

打通全链路

客户端的DSL公共组件的信息描述文件,目前还在@tencent/elk-dsl包内存放。假如客户端老师新增了某个公共组件,需要米鹿DSL编排系统的开发同步更新这份配置。这样的做法比较低效,无法实现双边信息的共享,同步。

manifest上传git

下一步会将公共组件的描述文件,上传到代码仓库,这样客户老师在修改(或新增)某个公共DSL组件后,同步更新一下配置信息文件。米鹿DSL可视化编排系统在每次加载时,都会拉取最新的描述文件,然后再根据描述文件,解析出远程组件。

当前系统能够生成,预览DSL代码,还没有关联到客户端的发布流程中。

打通链路

如上图所示,米鹿平台生成的DSL代码,需要能经过自动化的流水线,同步到客户端发布平台,最终能下发到客户端。目前和客户端老师确定的方案是,先将编排系统生成的DSL代码,推送到客户端的git仓库,经过人工调整,审核后合并到主干分支,这样git系统会自动关联流水线,后面的链路就打通了。

更多的支持

另外,会持续提供更多的技术支持,保障系统能生根落地,起到实际的应用价值。

  • 会持续优化DSL编排系统的使用体验,降低用户的使用门槛儿。
  • 针对客户端开发和产品提出的需求,及时响应和跟进
  • 进一步优化和完善文档,让用户更容易地使用系统
  • 不定期的分享,来推动系统的应用落地,解答用户的疑惑

附录

  1. 分享版PPT
  2. 使用文档
  3. 内网体验地址
  4. 项目工程代码
  5. 发版功能记录
  6. 客户端DSL组件库
  7. DSL语法介绍

致谢

感谢我们同组的小伙伴在开发系统时,提供的技术帮助,尤其是修展老师(lreliawu)带我熟悉了米鹿的相关业务背景,一飞老师(ifli)提供了分享推广的机会;感谢客户端的天泽老师(genesisli)、海洋老师(haiyangxie)对系统提出的宝贵意见;感谢领导亚杰老师(yajieliu)给予的工作任务。